/**
 * DICOM module.
 * @module dicom
 */
var dwv = dwv || {};
dwv.dicom = dwv.dicom || {};

/**
 * Data reader.
 * @class DataReader
 * @namespace dwv.dicom
 * @constructor
 * @param {Array} buffer The input array buffer.
 * @param {Boolean} isLittleEndian Flag to tell if the data is little or big endian.
 */
dwv.dicom.DataReader = function(buffer, isLittleEndian) {
	/**
	 * The main data view.
	 * @property view
	 * @private
	 * @type DataView
	 */
	var view = new DataView(buffer);
	// Set endian flag if not defined.
	if (typeof(isLittleEndian) === 'undefined') {
		isLittleEndian = true;
	}
	/**
	 * Read Uint8 (1 byte) data.
	 * @method readUint8
	 * @param {Number} byteOffset The offset to start reading from.
	 * @return {Number} The read data.
	 */
	this.readUint8 = function(byteOffset) {
		return view.getUint8(byteOffset, isLittleEndian);
	};
	/**
	 * Read Uint16 (2 bytes) data.
	 * @method readUint16
	 * @param {Number} byteOffset The offset to start reading from.
	 * @return {Number} The read data.
	 */
	this.readUint16 = function(byteOffset) {
		return view.getUint16(byteOffset, isLittleEndian);
	};
	/**
	 * Read Uint32 (4 bytes) data.
	 * @method readUint32
	 * @param {Number} byteOffset The offset to start reading from.
	 * @return {Number} The read data.
	 */
	this.readUint32 = function(byteOffset) {
		return view.getUint32(byteOffset, isLittleEndian);
	};
	/**
	 * Read Float32 (8 bytes) data.
	 * @method readFloat32
	 * @param {Number} byteOffset The offset to start reading from.
	 * @return {Number} The read data.
	 */
	this.readFloat32 = function(byteOffset) {
		return view.getFloat32(byteOffset, isLittleEndian);
	};
	/**
	 * Read Uint data of nBytes size.
	 * @method readNumber
	 * @param {Number} byteOffset The offset to start reading from.
	 * @param {Number} nBytes The number of bytes to read.
	 * @return {Number} The read data.
	 */
	this.readNumber = function(byteOffset, nBytes) {
		if (nBytes === 1) {
			return this.readUint8(byteOffset, isLittleEndian);
		} else if (nBytes === 2) {
			return this.readUint16(byteOffset, isLittleEndian);
		} else if (nBytes === 4) {
			return this.readUint32(byteOffset, isLittleEndian);
		} else if (nBytes === 8) {
			return this.readFloat32(byteOffset, isLittleEndian);
		} else {
			console.log("Non number: '" + this.readString(byteOffset, nBytes) + "'");
			throw new Error("Unsupported number size.");
		}
	};
	/**
	 * Read Uint8 array.
	 * @method readUint8Array
	 * @param {Number} byteOffset The offset to start reading from.
	 * @param {Number} size The size of the array.
	 * @return {Array} The read data.
	 */
	this.readUint8Array = function(byteOffset, size) {
		var data = new Uint8Array(size);
		var index = 0;
		for (var i = byteOffset; i < byteOffset + size; ++i) {
			data[index++] = this.readUint8(i);
		}
		return data;
	};
	/**
	 * Read Uint16 array.
	 * @method readUint16Array
	 * @param {Number} byteOffset The offset to start reading from.
	 * @param {Number} size The size of the array.
	 * @return {Array} The read data.
	 */
	this.readUint16Array = function(byteOffset, size) {
		var data = new Uint16Array(size / 2);
		var index = 0;
		for (var i = byteOffset; i < byteOffset + size; i += 2) {
			data[index++] = this.readUint16(i);
		}
		return data;
	};
	/**
	 * Read data as an hexadecimal string.
	 * @method readHex
	 * @param {Number} byteOffset The offset to start reading from.
	 * @return {Array} The read data.
	 */
	this.readHex = function(byteOffset) {
		// read and convert to hex string
		var str = this.readUint16(byteOffset).toString(16);
		// return padded
		return "0x0000".substr(0, 6 - str.length) + str.toUpperCase();
	};
	/**
	 * Read data as a string.
	 * @method readString
	 * @param {Number} byteOffset The offset to start reading from.
	 * @param {Number} nChars The number of characters to read.
	 * @return {String} The read data.
	 */
	this.readString = function(byteOffset, nChars) {
		var result = "";
		for (var i = byteOffset; i < byteOffset + nChars; ++i) {
			result += String.fromCharCode(this.readUint8(i));
		}
		return result;
	};
};

/**
 * Tell if a given syntax is a JPEG one.
 * @method isJpegTransferSyntax
 * @param {String} The transfer syntax to test.
 * @returns {Boolean} True if a jpeg syntax.
 */
dwv.dicom.isJpegTransferSyntax = function(syntax) {
	return syntax.match(/1.2.840.10008.1.2.4.5/) !== null ||
		syntax.match(/1.2.840.10008.1.2.4.6/) !== null ||
		syntax.match(/1.2.840.10008.1.2.4.7/) !== null;
};

/**
 * Tell if a given syntax is a JPEG-LS one.
 * @method isJpeglsTransferSyntax
 * @param {String} The transfer syntax to test.
 * @returns {Boolean} True if a jpeg-ls syntax.
 */
dwv.dicom.isJpeglsTransferSyntax = function(syntax) {
	return syntax.match(/1.2.840.10008.1.2.4.8/) !== null;
};

/**
 * Tell if a given syntax is a JPEG 2000 one.
 * @method isJpeg2000TransferSyntax
 * @param {String} The transfer syntax to test.
 * @returns {Boolean} True if a jpeg 2000 syntax.
 */
dwv.dicom.isJpeg2000TransferSyntax = function(syntax) {
	return syntax.match(/1.2.840.10008.1.2.4.9/) !== null;
};

/**
 * DicomParser class.
 * @class DicomParser
 * @namespace dwv.dicom
 * @constructor
 */
dwv.dicom.DicomParser = function() {
	/**
	 * The list of DICOM elements.
	 * @property dicomElements
	 * @type Array
	 */
	this.dicomElements = {};
	/**
	 * The number of DICOM Items.
	 * @property numberOfItems
	 * @type Number
	 */
	this.numberOfItems = 0;
	/**
	 * The pixel buffer.
	 * @property pixelBuffer
	 * @type Array
	 */
	this.pixelBuffer = [];
};

/**
 * Get the DICOM data pixel buffer.
 * @method getPixelBuffer
 * @returns {Array} The pixel buffer.
 */
dwv.dicom.DicomParser.prototype.getPixelBuffer = function() {
	return this.pixelBuffer;
};

/**
 * Append a DICOM element to the dicomElements member object.
 * Allows for easy retrieval of DICOM tag values from the tag name.
 * If tags have same name (for the 'unknown' and private tags cases), a number is appended
 * making the name unique.
 * @method appendDicomElement
 * @param {Object} element The element to add.
 */
dwv.dicom.DicomParser.prototype.appendDicomElement = function(element) {
	// find a good tag name
	var name = element.name;
	// count the number of items
	if (name === "Item") {
		++this.numberOfItems;
	}
	var count = 1;
	while (this.dicomElements[name]) {
		name = element.name + (count++).toString();
	}
	// store it
	this.dicomElements[name] = {
		"group": element.group,
		"element": element.element,
		"vr": element.vr,
		"vl": element.vl,
		"value": element.value
	};
};

/**
 * Read a DICOM tag.
 * @method readTag
 * @param reader The raw data reader.
 * @param offset The offset where to start to read.
 * @returns An object containing the tags 'group', 'element' and 'name'.
 */
dwv.dicom.DicomParser.prototype.readTag = function(reader, offset) {
	// group
	var group = reader.readHex(offset);
	// element
	var element = reader.readHex(offset + 2);
	// name
	var name = "dwv::unknown";
	if (dwv.dicom.dictionary[group]) {
		if (dwv.dicom.dictionary[group][element]) {
			name = dwv.dicom.dictionary[group][element][2];
		}
	}
	// return
	return {
		'group': group,
		'element': element,
		'name': name
	};
};

/**
 * Read a DICOM data element.
 * @method readDataElement
 * @param reader The raw data reader.
 * @param offset The offset where to start to read.
 * @param implicit Is the DICOM VR implicit?
 * @returns {Object} An object containing the element 'tag', 'vl', 'vr', 'data' and 'offset'.
 */
dwv.dicom.DicomParser.prototype.readDataElement = function(reader, offset, implicit) {
	// tag: group, element
	var tag = this.readTag(reader, offset);
	var tagOffset = 4;
	var vr; // Value Representation (VR)
	var vl; // Value Length (VL)
	var vrOffset = 0; // byte size of VR
	var vlOffset = 0; // byte size of VL
	// (private) Item group case
	if (tag.group === "0xFFFE") {
		vr = "N/A";
		vrOffset = 0;
		vl = reader.readUint32(offset + tagOffset);
		vlOffset = 4;
	}
	// non Item case
	else {
		// implicit VR?
		if (implicit) {
			vr = "UN";
			if (dwv.dicom.dictionary[tag.group]) {
				if (dwv.dicom.dictionary[tag.group][tag.element]) {
					vr = dwv.dicom.dictionary[tag.group][tag.element][0];
				}
			}
			vrOffset = 0;
			vl = reader.readUint32(offset + tagOffset + vrOffset);
			vlOffset = 4;
		} else {
			vr = reader.readString(offset + tagOffset, 2);
			vrOffset = 2;
			// long representations
			if (vr === "OB" || vr === "OF" || vr === "SQ" || vr === "OW" || vr === "UN") {
				vl = reader.readUint32(offset + tagOffset + vrOffset + 2);
				vlOffset = 6;
			}
			// short representation
			else {
				vl = reader.readUint16(offset + tagOffset + vrOffset);
				vlOffset = 2;
			}
		}
	}
	// check the value of VL
	if (vl === 0xffffffff) {
		vl = 0;
	}
	// data
	var data;
	var dataOffset = offset + tagOffset + vrOffset + vlOffset;
	if (vr === "US" || vr === "UL") {
		data = [reader.readNumber(dataOffset, vl)];
	} else if (vr === "OX" || vr === "OW") {
		data = reader.readUint16Array(dataOffset, vl);
	} else if (vr === "OB" || vr === "N/A") {
		data = reader.readUint8Array(dataOffset, vl);
	} else {
		data = reader.readString(dataOffset, vl);
		data = data.split("\\");
	}

	// total element offset
	var elementOffset = tagOffset + vrOffset + vlOffset + vl;
	// return
	return {
		'tag': tag,
		'vr': vr,
		'vl': vl,
		'data': data,
		'offset': elementOffset
	};
};

/**
 * Parse the complete DICOM file (given as input to the class).
 * Fills in the member object 'dicomElements'.
 * @method parse
 * @param buffer The input array buffer.
 */
dwv.dicom.DicomParser.prototype.parse = function(buffer) {
	var offset = 0;
	var implicit = false;
	var jpeg = false;
	var jpeg2000 = false;
	// default readers
	var metaReader = new dwv.dicom.DataReader(buffer);
	var dataReader = new dwv.dicom.DataReader(buffer);

	// 128 -> 132: magic word
	offset = 128;
	var magicword = metaReader.readString(offset, 4);
	if (magicword !== "DICM") {
		throw new Error("Not a valid DICOM file (no magic DICM word found)");
	}
	offset += 4;
	// 0x0002, 0x0000: MetaElementGroupLength
	var dataElement = this.readDataElement(metaReader, offset);
	var metaLength = parseInt(dataElement.data, 10);
	offset += dataElement.offset;
	// meta elements
	var metaStart = offset;
	var metaEnd = metaStart + metaLength;
	var i = metaStart;
	while (i < metaEnd) {
		// get the data element
		dataElement = this.readDataElement(metaReader, i, false);
		// check the transfer syntax
		if (dataElement.tag.name === "TransferSyntaxUID") {
			var syntax = dwv.utils.cleanString(dataElement.data[0]);
			// Implicit VR - Little Endian
			if (syntax === "1.2.840.10008.1.2") {
				implicit = true;
			}
			// Explicit VR - Little Endian (default): 1.2.840.10008.1.2.1
			// Deflated Explicit VR - Little Endian
			else if (syntax === "1.2.840.10008.1.2.1.99") {
				throw new Error("Unsupported DICOM transfer syntax (Deflated Explicit VR): " + syntax);
			}
			// Explicit VR - Big Endian
			else if (syntax === "1.2.840.10008.1.2.2") {
				dataReader = new dwv.dicom.DataReader(buffer, false);
			}
			// JPEG
			else if (dwv.dicom.isJpegTransferSyntax(syntax)) {
				jpeg = true;
				//console.log("JPEG compressed DICOM data: " + syntax);
				throw new Error("Unsupported DICOM transfer syntax (JPEG): " + syntax);
			}
			// JPEG-LS
			else if (dwv.dicom.isJpeglsTransferSyntax(syntax)) {
				//console.log("JPEG-LS compressed DICOM data: " + syntax);
				throw new Error("Unsupported DICOM transfer syntax (JPEG-LS): " + syntax);
			}
			// JPEG 2000
			else if (dwv.dicom.isJpeg2000TransferSyntax(syntax)) {
				console.log("JPEG 2000 compressed DICOM data: " + syntax);
				jpeg2000 = true;
			}
			// MPEG2 Image Compression
			else if (syntax === "1.2.840.10008.1.2.4.100") {
				throw new Error("Unsupported DICOM transfer syntax (MPEG2): " + syntax);
			}
			// RLE (lossless)
			else if (syntax === "1.2.840.10008.1.2.4.5") {
				throw new Error("Unsupported DICOM transfer syntax (RLE): " + syntax);
			}
		}
		// store the data element
		this.appendDicomElement({
			'name': dataElement.tag.name,
			'group': dataElement.tag.group,
			'vr': dataElement.vr,
			'vl': dataElement.vl,
			'element': dataElement.tag.element,
			'value': dataElement.data
		});
		// increment index
		i += dataElement.offset;
	}
	var startedPixelItems = false;
	var tagName = "";
	// DICOM data elements
	while (i < buffer.byteLength) {
		// get the data element
		try {
			dataElement = this.readDataElement(dataReader, i, implicit);
		} catch (err) {
			console.warn("Problem reading at " + i + " / " + buffer.byteLength +
				", after " + tagName + ".\n" + err);
		}
		tagName = dataElement.tag.name;
		// store pixel data from multiple items
		if (startedPixelItems) {
			if (tagName === "Item") {
				if (dataElement.data.length === 4) {
					console.log("Skipping Basic Offset Table.");
				} else if (dataElement.data.length !== 0) {
					console.log("Concatenating multiple pixel data items, length: " + dataElement.data.length);
					// concat does not work on typed arrays
					//this.pixelBuffer = this.pixelBuffer.concat( dataElement.data );
					// manual concat...
					var size = dataElement.data.length + this.pixelBuffer.length;
					var newBuffer = new Uint16Array(size);
					newBuffer.set(this.pixelBuffer, 0);
					newBuffer.set(dataElement.data, this.pixelBuffer.length);
					this.pixelBuffer = newBuffer;
				}
			} else if (tagName === "SequenceDelimitationItem") {
				startedPixelItems = false;
			} else {
				throw new Error("Unexpected tag in encapsulated pixel data: " + dataElement.tag.name);
			}
		}
		// check the pixel data tag
		if (tagName === "PixelData") {
			if (dataElement.data.length !== 0) {
				this.pixelBuffer = dataElement.data;
			} else {
				startedPixelItems = true;
			}
		}
		// store the data element
		this.appendDicomElement({
			'name': tagName,
			'group': dataElement.tag.group,
			'vr': dataElement.vr,
			'vl': dataElement.vl,
			'element': dataElement.tag.element,
			'value': dataElement.data
		});
		// increment index
		i += dataElement.offset;
	}
	// uncompress data
	if (jpeg) {
		// using jpgjs from https://github.com/notmasteryet/jpgjs
		// -> error with ffc3 and ffc1 jpeg jfif marker
		/*var j = new JpegImage();
		j.parse(this.pixelBuffer);
		var d = 0;
		j.copyToImageData(d);
		this.pixelBuffer = d.data;*/
	} else if (jpeg2000) {
		// decompress pixel buffer into Uint8 image
		var uint8Image = null;
		try {
			uint8Image = openjpeg(this.pixelBuffer, "j2k");
		} catch (error) {
			throw new Error("Cannot decode JPEG 2000 ([" + error.name + "] " + error.message + ")");
		}
		this.pixelBuffer = uint8Image.data;
	}
};

/**
 * Get an Image object from the read DICOM file.
 * @method createImage
 * @returns {View} A new Image.
 */
dwv.dicom.DicomParser.prototype.createImage = function() {
	// size
	if (!this.dicomElements.Columns) {
		throw new Error("Missing DICOM image number of columns");
	}
	if (!this.dicomElements.Rows) {
		throw new Error("Missing DICOM image number of rows");
	}
	var size = new dwv.image.Size(
		this.dicomElements.Columns.value[0],
		this.dicomElements.Rows.value[0]);
	// spacing
	var rowSpacing = 1;
	var columnSpacing = 1;
	if (this.dicomElements.PixelSpacing) {
		rowSpacing = parseFloat(this.dicomElements.PixelSpacing.value[0]);
		columnSpacing = parseFloat(this.dicomElements.PixelSpacing.value[1]);
	} else if (this.dicomElements.ImagerPixelSpacing) {
		rowSpacing = parseFloat(this.dicomElements.ImagerPixelSpacing.value[0]);
		columnSpacing = parseFloat(this.dicomElements.ImagerPixelSpacing.value[1]);
	}
	var spacing = new dwv.image.Spacing(columnSpacing, rowSpacing);

	// special jpeg 2000 case: openjpeg returns a Uint8 planar MONO or RGB image
	var syntax = dwv.utils.cleanString(
		this.dicomElements.TransferSyntaxUID.value[0]);
	var jpeg2000 = dwv.dicom.isJpeg2000TransferSyntax(syntax);
	// buffer data
	var buffer = null;
	// convert to 16bit if needed
	if (jpeg2000 && this.dicomElements.BitsAllocated.value[0] === 16) {
		var sliceSize = size.getSliceSize();
		buffer = new Int16Array(sliceSize);
		var k = 0;
		for (var p = 0; p < sliceSize; ++p) {
			buffer[p] = 256 * this.pixelBuffer[k] + this.pixelBuffer[k + 1];
			k += 2;
		}
	} else {
		buffer = new Int16Array(this.pixelBuffer.length);
		// unsigned to signed data if needed
		var shift = false;
		if (this.dicomElements.PixelRepresentation &&
			this.dicomElements.PixelRepresentation.value[0] == 1) {
			shift = true;
		}
		// copy
		for (var i = 0; i < this.pixelBuffer.length; ++i) {
			buffer[i] = this.pixelBuffer[i];
			if (shift && buffer[i] >= Math.pow(2, 15)) {
				buffer[i] -= Math.pow(2, 16);
			}
		}
	}
	// slice position
	var slicePosition = new Array(0, 0, 0);
	if (this.dicomElements.ImagePositionPatient) {
		slicePosition = [parseFloat(this.dicomElements.ImagePositionPatient.value[0]),
			parseFloat(this.dicomElements.ImagePositionPatient.value[1]),
			parseFloat(this.dicomElements.ImagePositionPatient.value[2])
		];
	}
	// image
	var image = new dwv.image.Image(size, spacing, buffer, [slicePosition]);
	// photometricInterpretation
	if (this.dicomElements.PhotometricInterpretation) {
		var photo = dwv.utils.cleanString(
			this.dicomElements.PhotometricInterpretation.value[0]).toUpperCase();
		if (jpeg2000 && photo.match(/YBR/)) {
			photo = "RGB";
		}
		image.setPhotometricInterpretation(photo);
	}
	// planarConfiguration
	if (this.dicomElements.PlanarConfiguration) {
		var planar = this.dicomElements.PlanarConfiguration.value[0];
		if (jpeg2000) {
			planar = 1;
		}
		image.setPlanarConfiguration(planar);
	}
	// rescale slope
	if (this.dicomElements.RescaleSlope) {
		image.setRescaleSlope(parseFloat(this.dicomElements.RescaleSlope.value[0]));
	}
	// rescale intercept
	if (this.dicomElements.RescaleIntercept) {
		image.setRescaleIntercept(parseFloat(this.dicomElements.RescaleIntercept.value[0]));
	}
	// meta information
	var meta = {};
	if (this.dicomElements.Modality) {
		meta.Modality = this.dicomElements.Modality.value[0];
	}
	if (this.dicomElements.StudyInstanceUID) {
		meta.StudyInstanceUID = this.dicomElements.StudyInstanceUID.value[0];
	}
	if (this.dicomElements.SeriesInstanceUID) {
		meta.SeriesInstanceUID = this.dicomElements.SeriesInstanceUID.value[0];
	}
	if (this.dicomElements.BitsStored) {
		meta.BitsStored = parseInt(this.dicomElements.BitsStored.value[0], 10);
	}
	image.setMeta(meta);
	// pixel representation
	var isSigned = 0;
	if (this.dicomElements.PixelRepresentation) {
		isSigned = this.dicomElements.PixelRepresentation.value[0];
	}
	// view
	var view = new dwv.image.View(image, isSigned);
	// window center and width
	var windowPresets = [];
	if (this.dicomElements.WindowCenter && this.dicomElements.WindowWidth) {
		var name;
		for (var j = 0; j < this.dicomElements.WindowCenter.value.length; ++j) {
			var width = parseFloat(this.dicomElements.WindowWidth.value[j], 10);
			if (width !== 0) {
				if (this.dicomElements.WindowCenterWidthExplanation) {
					name = this.dicomElements.WindowCenterWidthExplanation.value[j];
				} else {
					name = "Default" + j;
				}
				windowPresets.push({
					"center": parseFloat(this.dicomElements.WindowCenter.value[j], 10),
					"width": width,
					"name": name
				});
			}
		}
	}
	if (windowPresets.length !== 0) {
		view.setWindowPresets(windowPresets);
	} else {
		view.setWindowLevelMinMax();
	}

	return view;
};
